pnpm 동작 원리와 명령어 (npm·yarn 비교)
왜 pnpm인가
프로젝트마다
node_modules가 수백 MB씩 복사되고, 설치는 느리고, 내가 설치한 적도 없는 패키지가import되는 게 왜 되는지 모르겠다. pnpm은 이 세 가지를 store·하드링크·심볼릭링크로 풀어낸다.
npm으로 프로젝트 10개를 만들면 react가 10번 복사된다. pnpm은 react를 디스크에 딱 한 번만 두고 10개 프로젝트가 나눠 쓴다. 이 글에서는 pnpm이 그걸 어떻게 하는지, npm·yarn과 뭐가 다른지, 그리고 실제로 쓰는 명령어를 정리한다. (pnpm 10 기준)
npm·yarn은 어떻게 하고, 뭐가 문제였나
npm과 yarn(classic)은 node_modules를 평평하게(flat) 만든다. 내가 설치한 패키지든, 그 패키지가 의존하는 패키지든 전부 node_modules 최상단에 끌어올린다(hoisting).
내 프로젝트가 express만 설치했는데:
node_modules/
├── express/
├── body-parser/ ← express가 의존하는 것
├── cookie/ ← body-parser가 의존하는 것
├── qs/ ← 또 그 아래의 것
└── ... (수십~수백 개가 전부 최상단에)
이 방식에는 세 가지 문제가 있다.
- 디스크 중복: 프로젝트마다 이 전체 트리를 복사한다. 10개 프로젝트면 같은 react가 10벌.
- phantom dependency(유령 의존성): 최상단에 다 올라와 있으니, 내
package.json에 적지도 않은cookie를import해도 동작해버린다. 그러다 express 버전이 바뀌어cookie가 빠지면 갑자기 터진다. - 느림: 매번 파일을 복사하니 설치가 느리다.
yarn berry(2.x+)의 PnP는 아예
node_modules를 없애는 다른 방향으로 이 문제를 풀었지만, 기존 도구들과의 호환 문제가 있어 널리 퍼지진 못했다. pnpm은node_modules를 유지하면서 문제를 푼다.
pnpm의 핵심 아이디어 3가지
1. content-addressable store — 버전당 딱 한 번
pnpm은 모든 패키지를 머신에 단 하나뿐인 글로벌 store에 저장한다. 내용(content)의 해시로 주소를 매기기 때문에 같은 버전은 물리적으로 한 벌만 존재한다.
pnpm store path
# /Users/kdm/Library/pnpm/store/v10 (macOS 예시)
react@19를 쓰는 프로젝트가 몇 개든, 이 store 안엔 react@19가 하나뿐이다. 그래서 기존 패키지를 재사용하는 새 프로젝트를 추가하는 비용이 거의 0이다. (실측하면 디스크 절약이 npm 대비 70~80% 수준이라고들 한다.)
2. 하드링크로 node_modules 채우기
store의 파일을 프로젝트로 가져올 때 복사가 아니라 하드링크를 건다. 하드링크는 "같은 실제 파일(inode)을 가리키는 또 하나의 이름"이라, 추가 디스크를 먹지 않는다.
<글로벌 store>/<해시> ←─── 실제 파일 1개
↑ ↑
프로젝트A 프로젝트B ← 둘 다 하드링크(복사 아님)
du로 재면 각 프로젝트 node_modules가 수백 MB로 "보이지만", 그건 하드링크를 중복 집계한 착시다. 실제 물리 바이트는 store가 다 갖고 있고 프로젝트들은 이름만 나눠 가진다.
3. 심볼릭링크 기반 non-flat 구조
이게 pnpm의 진짜 특징이다. pnpm은 하드링크들을 node_modules/.pnpm/(가상 store)에 모아두고, 최상단 node_modules에는 내가 직접 설치한 것만 심볼릭링크로 노출한다.
express만 설치하면 이렇게 된다.
node_modules/
├── express → .pnpm/express@4.18.2/node_modules/express (심볼릭링크)
└── .pnpm/
├── express@4.18.2/node_modules/express ← 하드링크
├── body-parser@1.20.1/node_modules/... ← 하드링크
├── cookie@0.5.0/node_modules/... ← 하드링크
└── ... (전이 의존성은 전부 .pnpm 안에)
- 최상단엔
express만 보인다.body-parser,cookie는.pnpm안에 숨어 있다. - 그래서 내
package.json에 없는 걸import하면 실패한다 → phantom dependency가 원천 차단된다. - express 자신은
.pnpm/express@.../node_modules/안에서 자기 의존성(body-parser등)을 심볼릭링크로 정상적으로 찾는다.
즉 "복사 대신 링크, 평평함 대신 격리" — 이 한 줄이 pnpm의 전부다.
npm·yarn vs pnpm 정리
| npm / yarn classic | pnpm | |
|---|---|---|
| node_modules 구조 | 평평(flat, hoisting) | 심볼릭링크 non-flat |
| 같은 패키지 저장 | 프로젝트마다 복사 | 머신에 1벌(store) + 하드링크 |
| 디스크 | 프로젝트 수만큼 증가 | 버전당 1번, 추가 비용 ≈ 0 |
| phantom dependency | 발생함 | 구조적으로 차단 |
| 설치 속도 | 느림(복사) | 빠름(링크·병렬·캐시) |
| 모노레포 | 별도 도구(lerna 등) 필요했음 | 워크스페이스 1급 지원 |
주요 명령어
npm을 알면 대부분 그대로 대응된다.
기본
pnpm install # package.json 대로 전부 설치 (= npm install), 줄여서 pnpm i
pnpm add lodash # 의존성 추가 (= npm install lodash)
pnpm add -D vitest # devDependencies로 추가 (-D)
pnpm add -g typescript# 전역 설치 (-g)
pnpm remove lodash # 제거 (= npm uninstall), 줄여서 pnpm rm
pnpm update # 업데이트 (= npm update), 줄여서 pnpm up
pnpm outdated # 오래된 의존성 확인
스크립트 실행
pnpm run build # package.json scripts 실행
pnpm build # run 생략 가능 (= pnpm run build)
pnpm dlx create-next-app # 설치 없이 일회성 실행 (= npx)
pnpm exec eslint . # 프로젝트에 설치된 bin 실행
진단·관리
pnpm why lodash # 이 패키지가 왜 설치됐는지(누가 의존하는지) 추적
pnpm list # 설치된 의존성 트리 (= npm ls), 줄여서 pnpm ls
pnpm store path # 글로벌 store 위치
pnpm store prune # 아무도 참조 안 하는 store 파일 정리(GC)
pnpm store prune은 특히 유용하다. 오래된 버전들이 store에 쌓이면 이걸로 청소한다.
워크스페이스 (모노레포)
루트에 pnpm-workspace.yaml을 두면 여러 패키지를 한 번에 관리한다.
# pnpm-workspace.yaml
packages:
- "packages/*"
- "apps/*"
이러면 모든 멤버가 루트의 node_modules/.pnpm/를 공유한다. (버전당 하드링크 1개를 나눠 씀)
pnpm -r build # 모든 워크스페이스 패키지에서 build 실행 (recursive)
pnpm --filter web dev # 특정 패키지(web)에서만 실행
pnpm --filter web add axios # 특정 패키지에만 의존성 추가
패키지끼리 서로 참조할 땐 workspace: 프로토콜을 쓴다.
{ "dependencies": { "@myorg/ui": "workspace:*" } }
pnpm 10에서 알아둘 점
pnpm 10부터 의존성의 설치 스크립트(postinstall 등)가 기본적으로 실행되지 않는다. 공급망 공격을 막기 위한 보안 강화다. 빌드 스크립트가 필요한 패키지(예: esbuild)는 명시적으로 허용해줘야 한다.
pnpm approve-builds # 빌드 스크립트를 실행할 패키지를 대화형으로 승인
또는 package.json(또는 pnpm-workspace.yaml)에 onlyBuiltDependencies로 목록을 박아둔다. npm에서 넘어와서 "설치했는데 왜 안 되지?" 하는 경우 대부분 이거다.
단점·주의점
pnpm이 만능은 아니다. 단점 대부분은 "엄격함"과 "심볼릭링크 구조"에서 오는 대가다.
엄격함이 곧 "npm에선 되던 게 깨짐"
phantom dependency 차단이라는 최대 장점이 그대로 단점이 된다. 어떤 라이브러리가 자기 의존성을 package.json에 제대로 선언 안 했으면(그 라이브러리의 버그), npm의 flat 구조에선 우연히 동작하던 게 pnpm에선 "모듈 없음" 에러로 터진다. 내 잘못이 아니라 남의 패키지 잘못인데 내가 막히는 셈이라 원인 파악도 헷갈린다.
- 회피책:
.npmrc에node-linker=hoisted(npm처럼 평평하게) 또는public-hoist-pattern으로 특정 패키지만 끌어올림. 대신 pnpm의 격리 장점을 그만큼 포기한다.
심볼릭링크를 이해 못 하는 도구들
node_modules가 심볼릭링크 투성이라, node_modules를 평평하다고 가정하고 훑는 도구들이 오작동할 수 있다.
- 과거 사례: React Native/Metro 번들러, 일부 Jest 설정, 일부 Electron 빌더 등.
- 서버리스(AWS Lambda 등)에서
node_modules를 zip으로 말아 올릴 때 심볼릭링크가 깨져 배포가 안 되기도 한다 → 역시node-linker=hoisted로 우회. - 요즘은 많이 개선됐지만, "생태계가 npm 기준으로 만들어져 있다"는 근본 마찰은 남아 있다.
하드링크는 같은 파일시스템에서만
하드링크는 store와 프로젝트가 같은 볼륨(파티션)에 있어야 걸린다. Docker 볼륨 마운트나 다른 파티션에 프로젝트가 있으면 pnpm이 하드링크 대신 복사로 폴백해서 디스크 절약 장점이 사라진다. Docker 빌드에서 store 캐싱을 제대로 태우려면 npm보다 신경 쓸 게 많다.
그 밖에
- 글로벌 store = 공유된 가변 상태: 손상되거나
pnpm store prune을 잘못 돌리면 여러 프로젝트가 동시에 영향받는다. 시간이 지나면 store가 커져 주기적 정리가 필요하다. - 전환·협업 비용: 팀 전체가 같이 갈아타야 하고, 누군가 npm으로 설치하면 lockfile이 갈린다(
pnpm-lock.yamlvspackage-lock.json)..pnpm구조·workspace:·--filter등 익힐 것도 있다. - Windows: 예전엔 심볼릭링크 권한 문제가 있었고(지금은 junction으로 대부분 해결) 아주 가끔 엣지케이스가 남는다.
정리하면, 단점 대부분은 node-linker=hoisted로 npm처럼 되돌릴 수 있는(대신 장점 포기) 회피책이 있고 생태계 호환도 매년 나아지고 있다. 그래서 신규 프로젝트·모노레포엔 대체로 유리하지만, 레거시 도구에 강하게 묶였거나 특수한 배포 환경이면 npm/yarn이 마찰이 적을 수 있다.
정리
- pnpm의 3요소: content-addressable store(버전당 1벌) + 하드링크(복사 안 함) + 심볼릭링크 non-flat(격리).
- 그 결과 npm·yarn 대비 디스크 절약 · 빠른 설치 · phantom dependency 차단 · 모노레포 1급 지원을 얻는다.
- 명령어는 npm과 거의 1:1 대응이라 진입장벽이 낮다. 워크스페이스에선
-r,--filter,workspace:만 익히면 된다. - pnpm 10에선 빌드 스크립트가 기본 차단이라
pnpm approve-builds를 기억해두자. - 대신 그 엄격함·심볼릭링크 구조 탓에 일부 도구·배포 환경에선 깨질 수 있다. 막히면
node-linker=hoisted로 npm처럼 되돌릴 수 있다(장점은 포기).